package com.fary.security.authentication;

import com.fary.context.ApplicationEventPublisher;
import com.fary.context.ApplicationEventPublisherAware;
import com.fary.security.core.Authentication;
import com.fary.security.core.AuthenticationException;
import com.fary.util.Assert;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import javax.security.auth.login.AccountExpiredException;
import java.lang.reflect.InvocationTargetException;
import java.nio.file.ProviderNotFoundException;
import java.util.HashMap;
import java.util.Map;

public class DefaultAuthenticationEventPublisher implements AuthenticationEventPublisher, ApplicationEventPublisherAware {
    private final Log logger = LogFactory.getLog(getClass());

    private ApplicationEventPublisher applicationEventPublisher;
    private final HashMap<String, Constructor<? extends AbstractAuthenticationEvent>> exceptionMappings = new HashMap<>();
    private Constructor<? extends AbstractAuthenticationFailureEvent> defaultAuthenticationFailureEventConstructor;

    public DefaultAuthenticationEventPublisher() {
        this(null);
    }

    public DefaultAuthenticationEventPublisher(
            ApplicationEventPublisher applicationEventPublisher) {
        this.applicationEventPublisher = applicationEventPublisher;

        addMapping(BadCredentialsException.class.getName(),
                AuthenticationFailureBadCredentialsEvent.class);
        addMapping(UsernameNotFoundException.class.getName(),
                AuthenticationFailureBadCredentialsEvent.class);
        addMapping(AccountExpiredException.class.getName(),
                AuthenticationFailureExpiredEvent.class);
        addMapping(ProviderNotFoundException.class.getName(),
                AuthenticationFailureProviderNotFoundEvent.class);
        addMapping(DisabledException.class.getName(),
                AuthenticationFailureDisabledEvent.class);
        addMapping(LockedException.class.getName(),
                AuthenticationFailureLockedEvent.class);
        addMapping(AuthenticationServiceException.class.getName(),
                AuthenticationFailureServiceExceptionEvent.class);
        addMapping(CredentialsExpiredException.class.getName(),
                AuthenticationFailureCredentialsExpiredEvent.class);
        addMapping(
                "org.springframework.security.authentication.cas.ProxyUntrustedException",
                AuthenticationFailureProxyUntrustedEvent.class);
        addMapping(
                "org.springframework.security.oauth2.server.resource.InvalidBearerTokenException",
                AuthenticationFailureBadCredentialsEvent.class);
    }

    public void publishAuthenticationSuccess(Authentication authentication) {
        if (applicationEventPublisher != null) {
            applicationEventPublisher.publishEvent(new AuthenticationSuccessEvent(authentication));
        }
    }

    public void publishAuthenticationFailure(AuthenticationException exception,
                                             Authentication authentication) {
        Constructor<? extends AbstractAuthenticationEvent> constructor = getEventConstructor(exception);
        AbstractAuthenticationEvent event = null;

        if (constructor != null) {
            try {
                event = constructor.newInstance(authentication, exception);
            } catch (IllegalAccessException | InvocationTargetException | InstantiationException ignored) {
            }
        }

        if (event != null) {
            if (applicationEventPublisher != null) {
                applicationEventPublisher.publishEvent(event);
            }
        } else {
            if (logger.isDebugEnabled()) {
                logger.debug("No event was found for the exception " + exception.getClass().getName());
            }
        }
    }

    private Constructor<? extends AbstractAuthenticationEvent> getEventConstructor(AuthenticationException exception) {
        Constructor<? extends AbstractAuthenticationEvent> eventConstructor =
                this.exceptionMappings.get(exception.getClass().getName());
        return (eventConstructor == null ? this.defaultAuthenticationFailureEventConstructor : eventConstructor);
    }

    public void setApplicationEventPublisher(
            ApplicationEventPublisher applicationEventPublisher) {
        this.applicationEventPublisher = applicationEventPublisher;
    }

    /**
     * Sets additional exception to event mappings. These are automatically merged with
     * the default exception to event mappings that <code>ProviderManager</code> defines.
     *
     * @param additionalExceptionMappings where keys are the fully-qualified string name
     *                                    of the exception class and the values are the fully-qualified string name of the
     *                                    event class to fire.
     * @deprecated use {@link #setAdditionalExceptionMappings(Map)}
     */
    @Deprecated
    @SuppressWarnings({"unchecked"})
    public void setAdditionalExceptionMappings(Properties additionalExceptionMappings) {
        Assert.notNull(additionalExceptionMappings,
                "The exceptionMappings object must not be null");
        for (Object exceptionClass : additionalExceptionMappings.keySet()) {
            String eventClass = (String) additionalExceptionMappings.get(exceptionClass);
            try {
                Class<?> clazz = getClass().getClassLoader().loadClass(eventClass);
                Assert.isAssignable(AbstractAuthenticationFailureEvent.class, clazz);
                addMapping((String) exceptionClass,
                        (Class<? extends AbstractAuthenticationFailureEvent>) clazz);
            } catch (ClassNotFoundException e) {
                throw new RuntimeException("Failed to load authentication event class "
                        + eventClass);
            }
        }
    }

    /**
     * Sets additional exception to event mappings. These are automatically merged with
     * the default exception to event mappings that <code>ProviderManager</code> defines.
     *
     * @param mappings where keys are exception classes and values are event classes.
     * @since 5.3
     */
    public void setAdditionalExceptionMappings(Map<Class<? extends AuthenticationException>,
            Class<? extends AbstractAuthenticationFailureEvent>> mappings) {
        Assert.notEmpty(mappings, "The mappings Map must not be empty nor null");
        for (Map.Entry<Class<? extends AuthenticationException>, Class<? extends AbstractAuthenticationFailureEvent>> entry
                : mappings.entrySet()) {
            Class<?> exceptionClass = entry.getKey();
            Class<?> eventClass = entry.getValue();
            Assert.notNull(exceptionClass, "exceptionClass cannot be null");
            Assert.notNull(eventClass, "eventClass cannot be null");
            addMapping(exceptionClass.getName(), (Class<? extends AbstractAuthenticationFailureEvent>) eventClass);
        }
    }

    /**
     * Sets a default authentication failure event as a fallback event for any unmapped
     * exceptions not mapped in the exception mappings.
     *
     * @param defaultAuthenticationFailureEventClass is the authentication failure event class
     *                                               to be fired for unmapped exceptions.
     */
    public void setDefaultAuthenticationFailureEvent(
            Class<? extends AbstractAuthenticationFailureEvent> defaultAuthenticationFailureEventClass) {
        Assert.notNull(defaultAuthenticationFailureEventClass,
                "defaultAuthenticationFailureEventClass must not be null");
        try {
            this.defaultAuthenticationFailureEventConstructor = defaultAuthenticationFailureEventClass
                    .getConstructor(Authentication.class, AuthenticationException.class);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException("Default Authentication Failure event class "
                    + defaultAuthenticationFailureEventClass.getName() + " has no suitable constructor");
        }
    }

    private void addMapping(String exceptionClass,
                            Class<? extends AbstractAuthenticationFailureEvent> eventClass) {
        try {
            Constructor<? extends AbstractAuthenticationEvent> constructor = eventClass
                    .getConstructor(Authentication.class, AuthenticationException.class);
            exceptionMappings.put(exceptionClass, constructor);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException("Authentication event class "
                    + eventClass.getName() + " has no suitable constructor");
        }
    }
}